Übung 1 — Ein Assistent mit Router und Skills
Was du in dieser Übung baust: Den vollständigen Kern eines Assistenten: Eingang, Router mit festen Regeln und KI-Erkennung, Rückfragen, Bestätigungen, Not-Aus, drei Skills und einen Rückkanal. Als „Messenger“ dient ein Web-Formular, die Antworten erscheinen als Human Task.
Das Szenario
Dein Assistent soll Notizen verwalten:
| Skill | Beispiel | Risiko |
|---|---|---|
| Hilfe | „hilfe“ | 0 |
| Notiz merken | „notiz: Angebot Müller bis Freitag nachfassen“ | 1 |
| Notizen finden | „Was hatte ich zu Müller notiert?“ | 0 |
| Notizen löschen | „Lösch alle meine Notizen“ | 2 → Bestätigung |
Das ist bewusst schlicht — es geht um die Architektur. Wenn der Kern steht, ist jeder weitere Skill ein eigener kleiner Abschnitt.
Der Aufbau
[ eingang ] → [ einheitsformat ] → [ kontext_laden ] → [ entscheiden ] → [ verteilen ]
Web Form JavaScript Internal Storage JavaScript ▲ Switch
│ │ │
r_merken, r_verlauf, ◄───────┘ │ ├─→ ki_erkennen → ki_auswerten ─┘
r_vorgang, r_zusatz │ ├─→ notiz_merken ──┐
(Internal Storage) │ ├─→ notiz_loeschen ┴→ skill_db ─┐
│ ├─→ notiz_suchen → notiz_db → notiz_antwort ─┤
│ └─→ (antwort) ─────────────────────────────┤
│ ▼
│ [ rueckkanal ] → rk_vorgang, rk_verlauf
│ → [ anzeige ] Human Task
Teil 1 — Die Tabellen
Lege im Internal-Storage-Bereich diese Tabellen an (wie in Kurs 2):
CREATE TABLE chat_nachricht (nachricht_id TEXT PRIMARY KEY, kanal TEXT, absender_id TEXT, text TEXT, route TEXT, erstellt TEXT);
CREATE TABLE assistent_nutzer (kanal TEXT, absender_id TEXT, name TEXT, rolle TEXT, aktiv INTEGER, PRIMARY KEY (kanal, absender_id));
CREATE TABLE assistent_einstellung (schluessel TEXT PRIMARY KEY, wert TEXT, beschreibung TEXT, aktualisiert TEXT);
CREATE TABLE assistent_faehigkeit (schluessel TEXT PRIMARY KEY, name TEXT, beschreibung TEXT, beispiele TEXT, risiko INTEGER, ziel TEXT, aktiv INTEGER, sortierung INTEGER);
CREATE TABLE assistent_verlauf (id INTEGER PRIMARY KEY AUTOINCREMENT, unterhaltung_id TEXT, rolle TEXT, text TEXT, vorgang_id TEXT, erstellt TEXT DEFAULT (datetime('now')));
CREATE TABLE assistent_vorgang (id TEXT PRIMARY KEY, faehigkeit TEXT, text TEXT, parameter TEXT, status TEXT, risiko INTEGER, sicherheit REAL, unterhaltung_id TEXT, nachricht_id TEXT, antwort TEXT, erstellt TEXT, aktualisiert TEXT);
CREATE TABLE notiz (id INTEGER PRIMARY KEY AUTOINCREMENT, absender_id TEXT, text TEXT, erstellt TEXT);
Startdaten eintragen
Lege einen kleinen Hilfs-Abschnitt an: einen Manual Message Agent und dahinter einen Internal Storage Agent einrichten mit sql_statement: {{ sql }}. Schicke dann nacheinander diese drei Nachrichten ab (ersetze deinname durch einen Namen deiner Wahl, in Kleinbuchstaben):
{"sql": "INSERT INTO assistent_nutzer VALUES ('web', 'deinname', 'Dein Name', 'admin', 1)"}
{"sql": "INSERT INTO assistent_einstellung VALUES ('aktiv', '1', 'Not-Aus: 1 = aktiv, 0 = pausiert', datetime('now'))"}
{"sql": "INSERT INTO assistent_faehigkeit VALUES ('hilfe', 'Hilfe', 'Erklärt, was ich kann.', 'hilfe; was kannst du', 0, 'hilfe', 1, 90), ('notiz_merken', 'Notiz merken', 'Speichert eine Notiz für dich.', 'notiz: Angebot Müller bis Freitag; merk dir, dass Herr Kaiser Montag Urlaub hat; schreib dir auf: Druckerpatronen bestellen', 1, 'notiz_merken', 1, 10), ('notiz_suchen', 'Notizen finden', 'Findet deine gespeicherten Notizen zu einem Stichwort oder zeigt die letzten.', 'was hatte ich zu Müller notiert?; zeig meine Notizen; gibt es eine Notiz zum Drucker?', 0, 'notiz_suchen', 1, 20), ('notiz_loeschen', 'Notizen löschen', 'Löscht alle deine Notizen.', 'lösch alle meine Notizen; Notizen zurücksetzen', 2, 'notiz_loeschen', 1, 30)"}
Den Hilfs-Abschnitt kannst du behalten — er ist praktisch, um später Einträge zu korrigieren.
Teil 2 — Eingang und Kontext
eingang — Web Form Agent
Zwei Felder: absender_id (Textfeld, Pflicht) und text (mehrzeilig, Pflicht). Das Formular simuliert den Messenger: Du trägst ein, wer schreibt, und was.
einheitsformat — JavaScript Agent
Agent.receive = function() {
var p = this.incomingMessage().payload || {};
function sqlText(v, max) {
var s = (v === undefined || v === null) ? '' : String(v);
s = s.split("'").join('’').slice(0, max || 1000);
return s.trim() ? s : '-';
}
var text = String(p.text || '').trim();
var absender = String(p.absender_id || '').trim().toLowerCase().replace(/[^a-z0-9._-]/g, '');
// Ein Formular hat keine Nachrichten-ID — wir erzeugen eine aus Zeit und Zufall
var id = String(Date.now()) + String(Math.floor(Math.random() * 1000));
if (!absender || !text) { return; }
this.createMessage({
kanal: 'web',
nachricht_id: id,
unterhaltung_id: 'web-' + absender,
absender_id: absender,
absender_name: absender,
text: text,
text_sql: sqlText(text, 1000),
datei: null,
antwort_auf: id
});
};
kontext_laden — Internal Storage Agent
keep_message: true, sql_statement:
SELECT
(SELECT COUNT(*) FROM chat_nachricht WHERE nachricht_id = '{{ nachricht_id }}') AS schon,
(SELECT wert FROM assistent_einstellung WHERE schluessel = 'aktiv') AS aktiv,
(SELECT rolle FROM assistent_nutzer WHERE kanal = '{{ kanal }}' AND absender_id = '{{ absender_id }}' AND aktiv = 1) AS rolle,
(SELECT json_group_array(json_object('s', schluessel, 'n', name, 'b', beschreibung, 'x', beispiele, 'r', risiko, 'z', ziel))
FROM (SELECT * FROM assistent_faehigkeit WHERE aktiv = 1 ORDER BY sortierung)) AS faehigkeiten,
(SELECT json_group_array(json_object('r', rolle, 't', text))
FROM (SELECT rolle, text FROM assistent_verlauf WHERE unterhaltung_id = '{{ unterhaltung_id }}'
AND erstellt > datetime('now', '-2 hours') ORDER BY id DESC LIMIT 8)) AS verlauf,
(SELECT json_object('id', id, 'f', faehigkeit, 't', text, 's', status)
FROM assistent_vorgang WHERE unterhaltung_id = '{{ unterhaltung_id }}'
AND status IN ('bestaetigung', 'rueckfrage') AND erstellt > datetime('now', '-30 minutes')
ORDER BY erstellt DESC LIMIT 1) AS offen
Teil 3 — Der Router
entscheiden — JavaScript Agent
Das ist der Kern. Lies den Code einmal ganz durch, bevor du ihn einfügst — die Kommentare folgen der Reihenfolge aus Einheit 3.
Agent.receive = function() {
var p = this.incomingMessage().payload || {};
var NL = String.fromCharCode(10);
function json(v, d) {
if (v === undefined || v === null || v === '') { return d; }
if (typeof v !== 'string') { return v; }
try { return JSON.parse(v); } catch (e) { return d; }
}
function sqlText(v, max) {
var s = (v === undefined || v === null) ? '' : String(v);
s = s.split("'").join('’').slice(0, max || 1000);
return s.trim() ? s : '-';
}
// ---------- Kontext: erster Durchlauf aus rows, zweiter Durchlauf aus "kontext" ----------
var ki = p.ki || null;
var r0 = (p.rows && p.rows[0]) || {};
var kx = p.kontext || {
schon: parseInt(r0.schon, 10) || 0,
aktiv: (r0.aktiv === undefined || r0.aktiv === null) ? '1' : String(r0.aktiv),
rolle: r0.rolle ? String(r0.rolle) : '',
katalog: json(r0.faehigkeiten, []),
verlauf: json(r0.verlauf, []),
offen: json(r0.offen, null)
};
// 1. Schon verarbeitet? Dann still beenden.
if (!ki && kx.schon > 0) { return; }
var text = String(p.text || '').trim();
var vid = 'V-' + p.nachricht_id;
var o = {
kanal: p.kanal, nachricht_id: p.nachricht_id, unterhaltung_id: p.unterhaltung_id,
absender_id: p.absender_id, absender_name: p.absender_name,
text: text, text_sql: sqlText(text, 1000), antwort_auf: p.antwort_auf,
vorgang_id: vid, kontext: kx,
war_rueckfrage: p.war_rueckfrage ? 1 : 0, bestaetigt: p.bestaetigt ? 1 : 0
};
var route = 'antwort', f = 'unklar', status = 'laeuft', risiko = 0, sicherheit = 1;
var antwort = '', zusatz = 'SELECT 1', zusammenfassung = '';
function finde(s) {
s = String(s || '').toLowerCase();
for (var i = 0; i < kx.katalog.length; i++) { if (String(kx.katalog[i].s).toLowerCase() === s) { return kx.katalog[i]; } }
return null;
}
function hilfeText() {
var z = ['Das kann ich:'];
kx.katalog.forEach(function(k) {
if (k.s === 'hilfe') { return; }
var bsp = String(k.x || '').split(';')[0].trim();
z.push('- **' + k.n + '**: ' + k.b + (bsp ? ' – z. B. „' + bsp + '“' : ''));
});
z.push('');
z.push('Steuerung: „pause“ hält mich an, „weiter“ startet mich wieder.');
return z.join(NL);
}
function ausfuehren(fk) {
f = fk.s; risiko = parseInt(fk.r, 10) || 0;
if (fk.z === 'hilfe') { antwort = hilfeText(); status = 'beantwortet'; }
else { route = fk.z; }
}
if (ki) {
// ---------- Zweiter Durchlauf: Vorschlag der KI bewerten ----------
var fk = finde(ki.faehigkeit);
sicherheit = parseFloat(ki.sicherheit); if (isNaN(sicherheit)) { sicherheit = 0; }
zusammenfassung = String(ki.zusammenfassung || '');
if (ki.fehler) {
status = 'fehler';
antwort = 'Mein Sprachmodell antwortet gerade nicht. Versuch es bitte gleich noch einmal.';
} else if (!fk) {
f = 'unbekannt'; status = 'beantwortet';
antwort = 'Das kann ich noch nicht' + (zusammenfassung ? ' (' + zusammenfassung.replace(/[.]$/, '') + ')' : '') +
'. Ich habe den Wunsch notiert. Schreib „hilfe“, dann zeige ich dir, was ich kann.';
} else if (sicherheit < 0.6) {
f = fk.s;
if (o.war_rueckfrage) {
status = 'abgebrochen';
antwort = 'Das habe ich leider immer noch nicht sicher verstanden. Schreib „hilfe“, dann zeige ich dir, was ich kann.';
} else {
status = 'rueckfrage';
antwort = 'Meinst du **' + fk.n + '**? Schreib mir bitte genauer, was ich tun soll.';
}
} else if ((parseInt(fk.r, 10) || 0) >= 2 && !o.bestaetigt) {
f = fk.s; risiko = parseInt(fk.r, 10) || 0; status = 'bestaetigung';
antwort = 'Ich würde Folgendes tun: **' + fk.n + '**' + (zusammenfassung ? ' – ' + zusammenfassung : '') + NL +
'Soll ich das ausführen? Antworte mit **ja** oder **nein**.';
} else {
ausfuehren(fk);
}
} else if (!kx.rolle) {
// 2. Unbekannter Absender
f = 'abgelehnt'; status = 'abgebrochen';
antwort = 'Deine Nachricht kann ich nicht verarbeiten. Bitte wende dich an den Administrator.';
} else if (/^(pause|stopp?)[.!]?$/i.test(text)) {
// 3. Not-Aus
f = 'steuerung'; status = 'erledigt';
zusatz = "UPDATE assistent_einstellung SET wert = '0', aktualisiert = datetime('now') WHERE schluessel = 'aktiv'";
antwort = '⏸ Ich bin pausiert. Schreib „weiter“, dann mache ich weiter.';
} else if (/^(weiter|start)[.!]?$/i.test(text)) {
f = 'steuerung'; status = 'erledigt';
zusatz = "UPDATE assistent_einstellung SET wert = '1', aktualisiert = datetime('now') WHERE schluessel = 'aktiv'";
antwort = '▶ Ich bin wieder aktiv.';
} else if (kx.aktiv === '0') {
// 4. Pausiert
f = 'pausiert'; status = 'abgebrochen';
antwort = '⏸ Ich bin gerade pausiert und habe das nicht ausgeführt. Schreib „weiter“.';
} else if (kx.offen && kx.offen.s === 'bestaetigung' && /^(ja|jo|ok|okay|mach( das)?|los)\b/i.test(text)) {
// 5. Bestätigung: den gemerkten Auftrag ausführen
var fa = finde(kx.offen.f);
o.bestaetigt = 1;
o.text = String(kx.offen.t || ''); o.text_sql = sqlText(o.text, 1000);
zusatz = "UPDATE assistent_vorgang SET status = 'erledigt', antwort = 'bestätigt in " + vid + "', aktualisiert = datetime('now') WHERE id = '" + sqlText(kx.offen.id) + "'";
if (fa) { ausfuehren(fa); } else { status = 'fehler'; antwort = 'Diese Fähigkeit ist nicht mehr aktiv.'; }
} else if (kx.offen && kx.offen.s === 'bestaetigung' && /^(nein|nö|nee|abbrechen|stopp)\b/i.test(text)) {
f = String(kx.offen.f || 'unklar'); status = 'abgebrochen';
zusatz = "UPDATE assistent_vorgang SET status = 'abgebrochen', antwort = 'abgelehnt', aktualisiert = datetime('now') WHERE id = '" + sqlText(kx.offen.id) + "'";
antwort = 'Verstanden, ich mache das nicht.';
} else if (/^(hilfe|help|\?|was kannst du( alles)?\??)$/i.test(text)) {
// 6. Feste Befehle
f = 'hilfe'; status = 'beantwortet'; antwort = hilfeText();
} else if (/^(notiz|notiere|merk dir|schreib dir auf)\b/i.test(text)) {
// 7. Eindeutige Muster
route = 'notiz_merken'; f = 'notiz_merken'; risiko = 1;
} else {
// 9. Keine Regel greift: KI-Erkennung
route = 'ki';
var kitext = text;
if (kx.offen && kx.offen.s === 'rueckfrage') {
// Antwort auf eine Rückfrage: ursprünglichen Text und Ergänzung zusammen erkennen lassen
kitext = String(kx.offen.t || '') + NL + 'Ergänzung auf deine Rückfrage: ' + text;
o.war_rueckfrage = 1;
o.text = kitext; o.text_sql = sqlText(kitext, 1000);
zusatz = "UPDATE assistent_vorgang SET status = 'abgebrochen', antwort = 'zusammengeführt in " + vid + "', aktualisiert = datetime('now') WHERE id = '" + sqlText(kx.offen.id) + "'";
}
o.ki_katalog = kx.katalog.map(function(k) { return '- ' + k.s + ': ' + k.b + (k.x ? ' | ' + k.x : ''); }).join(NL);
var vl = (kx.verlauf || []).slice(0, 6).reverse().map(function(x) { return (x.r === 'assistent' ? 'Assistent: ' : 'Nutzer: ') + String(x.t || '').slice(0, 300); });
o.ki_verlauf = vl.length ? vl.join(NL) : '(noch kein Verlauf)';
o.ki_text = kitext;
}
// Eine neue Nachricht, die keine Antwort auf die offene Frage war, beendet diese
if (!ki && zusatz === 'SELECT 1' && kx.offen && kx.offen.id) {
zusatz = "UPDATE assistent_vorgang SET status = 'abgebrochen', antwort = 'überholt durch " + vid + "', aktualisiert = datetime('now') WHERE id = '" + sqlText(kx.offen.id) + "' AND status IN ('rueckfrage', 'bestaetigung')";
}
// ---------- Ergebnis ----------
o.route = route;
o.faehigkeit = f;
if (route === 'antwort') { o.antwort_text = antwort; o.vorgang_status = status; }
var erster = !ki;
o.sql_merken = erster
? "INSERT OR IGNORE INTO chat_nachricht (nachricht_id, kanal, absender_id, text, route, erstellt) VALUES ('" +
[sqlText(p.nachricht_id), sqlText(p.kanal), sqlText(p.absender_id), sqlText(text, 500), sqlText(f)].join("', '") + "', datetime('now'))"
: 'SELECT 1';
o.sql_verlauf = erster
? "INSERT INTO assistent_verlauf (unterhaltung_id, rolle, text, vorgang_id) VALUES ('" +
[sqlText(p.unterhaltung_id), 'nutzer', sqlText(text, 1500), sqlText(vid)].join("', '") + "')"
: 'SELECT 1';
o.sql_vorgang = "INSERT INTO assistent_vorgang (id, faehigkeit, text, parameter, status, risiko, sicherheit, unterhaltung_id, nachricht_id, erstellt, aktualisiert) VALUES ('" +
[sqlText(vid), sqlText(f), sqlText(o.text, 2000), sqlText(JSON.stringify({ zusammenfassung: zusammenfassung })), sqlText(status)].join("', '") + "', " +
risiko + ', ' + sicherheit + ", '" + sqlText(p.unterhaltung_id) + "', '" + sqlText(p.nachricht_id) + "', datetime('now'), datetime('now')) " +
'ON CONFLICT(id) DO UPDATE SET faehigkeit = excluded.faehigkeit, text = excluded.text, parameter = excluded.parameter, ' +
'risiko = excluded.risiko, sicherheit = excluded.sicherheit, aktualisiert = excluded.aktualisiert, ' +
"status = CASE WHEN assistent_vorgang.status IN ('erledigt', 'abgebrochen') THEN assistent_vorgang.status ELSE excluded.status END";
o.sql_zusatz = zusatz;
this.createMessage(o);
};
Die vier Speicher-Agents des Routers
Vier Internal Storage Agents hängen direkt an entscheiden, jeweils mit keep_message: false und ohne Empfänger:
| Agent | sql_statement |
|---|---|
r_merken | {{ sql_merken }} |
r_verlauf | {{ sql_verlauf }} |
r_vorgang | {{ sql_vorgang }} |
r_zusatz | {{ sql_zusatz }} |
verteilen — Switch Agent
| Empfänger | Bedingung |
|---|---|
rueckkanal | '{{ route }}' == 'antwort' |
ki_erkennen | '{{ route }}' == 'ki' |
notiz_merken | '{{ route }}' == 'notiz_merken' |
notiz_suchen | '{{ route }}' == 'notiz_suchen' |
notiz_loeschen | '{{ route }}' == 'notiz_loeschen' |
ki_erkennen — Generative AI Agent
output_format: json, emit_on_error: true, Prompt:
Du ordnest Nachrichten an einen digitalen Assistenten genau einer Fähigkeit zu.
FÄHIGKEITEN (schluessel: Beschreibung | Beispiele)
{{ ki_katalog }}
BISHERIGER VERLAUF (älteste zuerst)
{{ ki_verlauf }}
NEUE NACHRICHT
<<<
{{ ki_text }}
>>>
Regeln:
- Passt keine Fähigkeit (Smalltalk, andere Aufgaben), antworte mit "unbekannt".
- Wenn du raten müsstest, setze "sicherheit" unter 0.6.
Antworte ausschließlich mit diesem JSON-Objekt, ohne weiteren Text:
{"faehigkeit": "<schluessel oder unbekannt>", "sicherheit": <Zahl von 0 bis 1>, "zusammenfassung": "<ein kurzer Satz, was gewünscht ist>"}
ki_auswerten — JavaScript Agent
Er liest das Ergebnis der KI und schickt die ursprüngliche Nachricht mit dem Feld ki zurück an entscheiden. Verbinde den Ausgang also mit entscheiden — das ist die Schleife aus Einheit 3.
Agent.receive = function() {
var p = this.incomingMessage().payload || {};
var src = p.last_message || {}; // die Nachricht, die in die KI hineinging
var ki;
if (p.success === false) {
ki = { fehler: 1 };
} else if (p.faehigkeit !== undefined) {
ki = p; // JSON wurde bereits zerlegt
} else {
var g = String(p.generation || '');
var a = g.indexOf('{'), b = g.lastIndexOf('}');
try { ki = JSON.parse(g.slice(a, b + 1)); } catch (e) { ki = { fehler: 1 }; }
}
var r = {};
for (var k in src) { if (src.hasOwnProperty(k) && k !== 'rows') { r[k] = src[k]; } }
r.ki = {
faehigkeit: String(ki.faehigkeit || 'unbekannt').trim(),
sicherheit: ki.sicherheit,
zusammenfassung: String(ki.zusammenfassung || '').slice(0, 300),
fehler: ki.fehler ? 1 : 0
};
this.createMessage(r);
};
Wie der Generative AI Agent sein Ergebnis zurückgibt (bereits zerlegtes JSON, generation als Text, Originalnachricht in last_message), siehst du im Log der ersten Ausführung. Der Code oben deckt die üblichen Varianten ab — passe ihn an, falls dein Log anders aussieht.
Teil 4 — Die Skills
notiz_merken — JavaScript Agent
Agent.receive = function() {
var p = this.incomingMessage().payload || {};
function sqlText(v, max) { var s = (v === undefined || v === null) ? '' : String(v); s = s.split("'").join('’').slice(0, max || 1000); return s.trim() ? s : '-'; }
var t = String(p.text || '').replace(/^(notiz|notiere|merk dir|schreib dir auf)\s*[:,\-]?\s*(dass\s+)?/i, '').trim();
this.createMessage({
kanal: p.kanal, unterhaltung_id: p.unterhaltung_id, antwort_auf: p.antwort_auf,
vorgang_id: p.vorgang_id, faehigkeit: 'notiz_merken',
sql: "INSERT INTO notiz (absender_id, text, erstellt) VALUES ('" + sqlText(p.absender_id) + "', '" + sqlText(t, 1000) + "', datetime('now'))",
antwort_text: '📝 Notiert: „' + t + '“',
vorgang_status: 'erledigt'
});
};
notiz_loeschen — JavaScript Agent
Agent.receive = function() {
var p = this.incomingMessage().payload || {};
var abs = String(p.absender_id || '').split("'").join('');
this.createMessage({
kanal: p.kanal, unterhaltung_id: p.unterhaltung_id, antwort_auf: p.antwort_auf,
vorgang_id: p.vorgang_id, faehigkeit: 'notiz_loeschen',
sql: "DELETE FROM notiz WHERE absender_id = '" + abs + "'",
antwort_text: '🗑 Alle deine Notizen sind gelöscht.',
vorgang_status: 'erledigt'
});
};
skill_db — Internal Storage Agent
Beide Skills oben schicken an diesen Agent: sql_statement: {{ sql }}, keep_message: true, Empfänger: rueckkanal.
notiz_suchen → notiz_db → notiz_antwort
notiz_suchen (JavaScript Agent) baut die Suche aus den Wörtern der Frage:
Agent.receive = function() {
var p = this.incomingMessage().payload || {};
var abs = String(p.absender_id || '').split("'").join('');
var STOP = /^(was|hatte|habe|hab|ich|zu|zum|zur|der|die|das|den|dem|notiz|notizen|notiert|meine|mein|zeig|zeige|gibt|es|eine|einen|alle|über|ueber|wegen|von|mir|bitte)$/i;
var woerter = String(p.text || '').toLowerCase().replace(/[^a-zäöüß0-9 ]+/g, ' ').split(/\s+/)
.filter(function(w) { return w.length >= 3 && !STOP.test(w); }).slice(0, 4);
var bedingung = woerter.length
? ' AND (' + woerter.map(function(w) { return "lower(text) LIKE '%" + w + "%'"; }).join(' OR ') + ')'
: '';
this.createMessage({
kanal: p.kanal, unterhaltung_id: p.unterhaltung_id, antwort_auf: p.antwort_auf,
vorgang_id: p.vorgang_id, faehigkeit: 'notiz_suchen', suchwoerter: woerter.join(', '),
sql: "SELECT id, text, substr(erstellt, 1, 10) AS tag FROM notiz WHERE absender_id = '" + abs + "'" + bedingung + ' ORDER BY id DESC LIMIT 10'
});
};
notiz_db (Internal Storage Agent): sql_statement: {{ sql }}, keep_message: true.
notiz_antwort (JavaScript Agent) formuliert die Antwort — per Skript, nicht per KI:
Agent.receive = function() {
var p = this.incomingMessage().payload || {};
var NL = String.fromCharCode(10);
var rows = p.rows || [];
var kopf = p.suchwoerter ? 'Notizen zu „' + p.suchwoerter + '“' : 'Deine letzten Notizen';
var text = rows.length
? '**' + kopf + '** (' + rows.length + '):' + NL + rows.map(function(r) { return '- ' + r.text + ' _(' + r.tag + ')_'; }).join(NL)
: 'Dazu habe ich keine Notiz gefunden.';
this.createMessage({
kanal: p.kanal, unterhaltung_id: p.unterhaltung_id, antwort_auf: p.antwort_auf,
vorgang_id: p.vorgang_id, faehigkeit: 'notiz_suchen',
antwort_text: text, vorgang_status: 'beantwortet'
});
};
Teil 5 — Der Rückkanal
rueckkanal — JavaScript Agent
Empfängt von verteilen (direkte Antworten), skill_db und notiz_antwort.
Agent.receive = function() {
var p = this.incomingMessage().payload || {};
function sqlText(v, max) { var s = (v === undefined || v === null) ? '' : String(v); s = s.split("'").join('’').slice(0, max || 1000); return s.trim() ? s : '-'; }
var text = String(p.antwort_text || '');
if (!text) { return; }
var st = String(p.vorgang_status || 'beantwortet');
this.createMessage({
kanal: p.kanal, unterhaltung_id: p.unterhaltung_id, antwort_auf: p.antwort_auf,
vorgang_id: p.vorgang_id, antwort_text: text,
sql_vorgang: "UPDATE assistent_vorgang SET status = CASE WHEN status IN ('erledigt', 'abgebrochen') THEN status ELSE '" + sqlText(st) +
"' END, antwort = '" + sqlText(text, 1500) + "', aktualisiert = datetime('now') WHERE id = '" + sqlText(p.vorgang_id) + "'",
sql_verlauf: "INSERT INTO assistent_verlauf (unterhaltung_id, rolle, text, vorgang_id) VALUES ('" +
sqlText(p.unterhaltung_id) + "', 'assistent', '" + sqlText(text, 1500) + "', '" + sqlText(p.vorgang_id) + "')"
});
};
Dahinter:
rk_vorgang— Internal Storage Agent,{{ sql_vorgang }},keep_message: falserk_verlauf— Internal Storage Agent,{{ sql_verlauf }},keep_message: falseanzeige— Human Task Agent, der{{ antwort_text }}anzeigt (wie in Kurs 1)
In Kurs 8 ersetzt du anzeige durch den Versand an einen echten Messenger.
Router (r_vorgang) und Rückkanal (rk_vorgang) schreiben beide in denselben Vorgang. Weil der Router den Vorgang per Upsert anlegt und der Rückkanal ihn nur aktualisiert, kann es bei direkten Antworten passieren, dass das Update vor dem Anlegen ankommt. Das ist harmlos: Der Router schreibt bei direkten Antworten bereits den richtigen Status mit.
Teil 6 — Testen
Schicke diese Nachrichten nacheinander über das Formular (immer mit deinem Absender):
| # | Text | Erwartung |
|---|---|---|
| 1 | hilfe | Liste der drei Skills — ohne KI (prüfe im Log: ki_erkennen lief nicht) |
| 2 | notiz: Angebot Müller bis Freitag nachfassen | „📝 Notiert …“ — ebenfalls ohne KI |
| 3 | Ich darf nicht vergessen, Herrn Kaiser wegen der Schulung anzurufen | KI → notiz_merken |
| 4 | Was war nochmal mit Müller? | KI → notiz_suchen, zeigt Notiz aus Schritt 2 |
| 5 | Wie wird das Wetter morgen? | „Das kann ich noch nicht …“ |
| 6 | Lösch alle meine Notizen | Bestätigungsfrage |
| 7 | nein | „Verstanden, ich mache das nicht.“ |
| 8 | pause, danach zeig meine Notizen | zweite Antwort: pausiert |
| 9 | weiter, danach zeig meine Notizen | Notizen werden angezeigt |
| 10 | Absender fremd, Text hilfe | Ablehnung |
Sieh dir nach dem Test die Tabelle assistent_vorgang an. Für jede Nachricht gibt es genau einen Vorgang mit einem abschließenden Status, und die Tabelle chat_nachricht enthält jede Nachricht genau einmal.
Antwort bleibt aus — was prüfen?
- Log von
entscheiden: Kommt eine Nachricht an? Enthält sierows? Wenn nicht, istkeep_messageankontext_ladennicht gesetzt. schon = 1beim ersten Versuch? Dann hat ein vorheriger Testlauf die ID schon vermerkt — passiert bei erneutem Ausführen einer alten Nachricht (Re-Emit). Das ist der Doppelschutz bei der Arbeit.- SQL-Fehler in einem Storage Agent? Prüfe das Log von
r_vorgang— fehlende Spalten oder Tippfehler in Tabellennamen sind die häufigste Ursache. - Keine Rolle gefunden? Kanal (
web) undabsender_idmüssen exakt dem Eintrag inassistent_nutzerentsprechen, inklusive Kleinschreibung.
Erweiterungen
Wenn alles läuft, probiere:
- Wiederholversuch: Schickt das Modell keine brauchbare Antwort, einmal erneut fragen (Feld
ki_versuch, Switch Agent zwischenki_auswertenundentscheiden). - „Was läuft?“ als feste Regel mit der Abfrage aus Einheit 5.
- Einen vierten Skill in einem eigenen Workflow, angeschlossen über Receiver und Source — ohne den Router-Code anzufassen, nur mit einem neuen Katalogeintrag und einem neuen Ausgang am Switch Agent.
Weiter geht es mit Übung 2: Dein Assistent lernt dazu.